refactor(desktop): make the session UI store the only pending authority - #4113
Draft
Astro-Han wants to merge 10 commits into
Draft
refactor(desktop): make the session UI store the only pending authority#4113Astro-Han wants to merge 10 commits into
Astro-Han wants to merge 10 commits into
Conversation
Astro-Han
force-pushed
the
refactor/renderer-single-pending-authority
branch
from
August 29, 2026 03:05
9ccc637 to
b292f0f
Compare
`setActiveId` and its siblings were function declarations in the `useAppShellSessionWorkspace` body, so every AppShell render handed consumers new identities. `activateSession` alone invalidated `openSession`'s `useCallback`, then the Session navigation controller's `commands`, then the host's `rowActions` and `onSelectSession`, then `renderSessionRow` — which defeated `SessionNavRow`'s `memo` on every commit. One session switch re-rendered all 32 sidebar rows about twenty times, and each Astryx button rewrote its inline `anchor-name` per render, so a switch also produced roughly 2,500 style writes. Every dependency these actions close over is a ref box, a React state setter, or a method of the once-created session-UI controller, so they are constant by construction rather than by discipline. `createSessionWorkspaceActions` moves them out of the render body and the hook instantiates it once; `refreshSessions` and `seedSessions` get the same treatment. This is why they are not routed through `useStableActions`, whose facade exists for factories whose closures do capture changing deps. Measured by alternating the two identities inside one running instance, six switches each: row renders 459 to 85, DOM mutations 3,438 to 1,992, renderer JS 521 ms to 380 ms, and renderer CPU under a repeated-switch loop 34% to 24% with peaks falling from 77% to 55%. Two imports in the session-list hook gain their `.js` extension so the workspace module tree loads under Node, which the new identity contract test needs. Generated-by: Claude Code
`@maka/ui`'s `formatAbsoluteTimestamp` was a second copy of the `Intl` options `@maka/core/relative-time` already owned, and it built a formatter on every call — the session sidebar reads one per row for the tooltip and one for the row's accessible name, so a single session switch constructed roughly 1,300 of them. Core's own cache could not have absorbed that either: `getRelativeFormat` and `getAbsoluteFormat` shared one `cachedLocale` and cleared each other on a miss, so alternating readings of the same timestamp rebuilt a formatter every call. Core now caches each formatter with its own locale and exports `formatAbsoluteTimestamp`; the UI copy is re-exported rather than reimplemented, so the tooltip and the accessible name cannot drift. Profiling attributed about 33 ms per session switch to the constructions. An A/B inside one running instance, swapping a memoising `Intl.DateTimeFormat` in and out six times each, moved renderer JS by less than the run-to-run spread — this is a duplicate-authority removal, not a measurable win. Generated-by: Claude Code
The rail's cost has had several independent causes — `setActiveId` changing identity on every AppShell render, `Intl` formatters rebuilt per row, catalog refreshes replacing unchanged row objects — and each was invisible to the others. Asserting identities pins one mechanism in one hook; the next plain function declaration upstream passes every existing check, because the dependency arrays stay correct. So the assertion is on the outcome: switching a session may write at most three inline styles per rail row. Inline `style` is the dominant term, since every Astryx button removes and re-adds its `anchor-name` per render, and it needs no React internals to observe — a `MutationObserver` over the rail is the whole probe. Measured on the new twelve-row fixture: 4 writes when the rail behaves, the leaving and the arriving row at two each, stable across runs; 336 with `setActiveId` restored to a per-render identity. The budget of 36 sits an order of magnitude clear of both. This also covers the unattributed commit cascade in apache#4109: whatever raises the number of commits a switch produces shows up here. Generated-by: Claude Code
The budget was a total, scaled by row count, and one-sided. Each of those let a real regression through. A total of `3 * rows` is 1.5 whole-rail renders, so a change that renders the rail exactly once more than it should stayed under it — and that is the likeliest regression, because `renderSessionRow` depends on `rowActions`, `sessionMeta` and three Sets, any of which becoming a fresh object per render defeats `SessionNavRow`'s memo for every row at once. The identity test could not see it either: it reads the workspace hook's return value, not what AppShell assembles from it. Attributing each write to its row removes the hole and the row-count coupling together — a switch touches the leaving row and the arriving row, whatever the rail's length — and it asserts the fix's own missing middle, that memo holding means untouched rows do no DOM work. Counting remounts closes the other side: React sets attributes before insertion, so an attribute-only observer reads a whole rail unmounting and remounting as CHEAPER than a re-render. `styleWrites > 0` is the counter's liveness check. Every write counted comes from an Astryx ref callback with no `useCallback` around it; if that is ever memoised upstream, healthy and regressed readings both collapse to zero and a one-sided budget passes forever. The two fixed `waitForTimeout` calls were the only thing keeping a slow machine out of the measurement window, with `retries: 0` behind them. Polling until the counter is quiet for ~300ms states the actual precondition, and runs faster: 2.2s against 3.6s. The identity test now derives its keys from the hook's return value. The hand-kept list covered 11 of the 23 functions it returns and would have kept covering 11 as more were added. Verified by reverting the fix in the built renderer bundle: the run fails on rows-touched, and passes three times in a row with the fix in place. Generated-by: Claude Code
Extracting the workspace actions gave this type an owner and an export. Leaving the three local copies in place would have made the PR that merged one duplicate authority create another. Generated-by: Claude Code
Once the implementation moved to `@maka/core/relative-time`, the export left behind in `chat-display-helpers` held nothing — it was a second name for the same function, and `relative-time.tsx` reached the one module through both names at once. Drift is prevented by there being a single implementation, not by which file the callers name. `formatAbsoluteTimestamp` is not in the package's public exports, so this moves three imports and removes a concept without changing a contract. Generated-by: Claude Code
Four in-flight facts each had two representations: a `Set` ref that the duplicate guard read, and a `Record<string, boolean>` in the session UI store that the disabled mask rendered. Nothing read across the pair, and the two were kept aligned by hand at every claim, every `finally`, and two separate teardown paths — `clearOwnedSessionState` deleting from the refs, `clearSessionUiState` wiping the maps, correct only because one calls the other in that order. The ref half is redundant. State replacement in the controller is synchronous, so a claim is visible to the next `getState()` in the same task; the guard can read the map it already writes. `createPendingClaim` puts both halves behind one compare-and-set, and message retry, stop, permission mode, and session model each get a claim in place of a ref plus a setter plus a pair of AppShell helpers. Removed along the way: `addPendingSessionAction` and `clearPendingSessionAction` with their unused optional-setter parameter, three private copies of `omitSessionKey` that existed only to maintain the map half, the four `set*BySession` setters those copies fed, and the two teardown paths that had to agree. One behaviour change. `setPermissionMode` now claims before its bypass confirmation rather than after, so a second click cannot open a second dialog; the control reads as pending while the user decides, which is what is true. A cancelled confirmation releases the claim. Generated-by: Claude Code
`useKeyedPendingRegistry` was generic over `trackState` and `autoClearMs` because it served three instances. Two of them moved to the session UI store, and both options had only ever had one consumer: the turn footer needs the reactive snapshot for its disabled mask and the timers so a dropped `sessions:changed` cannot disable a button forever. With no variation left, the options object, the `trackState` branch, and the seeded-but-unused `keys` on a ref-only registry are generality nothing asks for. `clearAll()` had no caller. The unmount cleanup in `app-shell-effects` walked `timersRef` and `keysRef` itself — the same work, reaching around the method that exists to do it. It now calls `clearAll()`, which is why `timersRef` no longer needs to be public. Generated-by: Claude Code
The controller's surface was restated twice on the way to its consumers: `useAppShellSessionUiState` returned the controller plus a member-by-member copy of it, `useAppShellSessionWorkspace` copied that copy, and AppShell destructured the result. Adding a map to the store meant editing three lists that nothing keeps in agreement, and the copies carried no semantics of their own — `sessionUiController` was already in the same destructure. The hook now returns the controller. Call sites name it, which is longer to read and shorter to keep true. Generated-by: Claude Code
`stable-actions.ts` was split from its hook on the stated grounds that keeping it React-free made it testable from `node:test`. No such test was ever written, and `createDelegatingActions` had exactly one consumer. The claim is now honoured the other way round: the facade moves in with `useStableActions` and the contract is asserted through the hook, where React's commit semantics are part of what is being promised. The test covers all nine call sites by construction, because it constrains the mechanism rather than each factory: identities fixed across renders, and calls delegating to the latest committed closures. It fails on `return actions`. What it cannot catch is a factory that goes through neither this hook nor a once-created object — a bare function declaration in a hook body passes types and `useExhaustiveDependencies` alike. That gap is why the Session rail carries an outcome budget as well, and the comment in `session-workspace-actions.ts` that argued for one mechanism over the other now defers to the tests instead. Generated-by: Claude Code
Astro-Han
force-pushed
the
refactor/renderer-single-pending-authority
branch
from
August 29, 2026 03:08
b292f0f to
9df3019
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A simplification audit of the session-workspace slice, following the perf fix in #4110. Four concepts leave the renderer. Nothing is added that the code did not already imply.
One authority for "an action is in flight." Four facts each had two representations: a
Setref that the duplicate guard read, and aRecord<string, boolean>in the session UI store that the disabled mask rendered — message retry, stop, permission mode, session model. Nothing read across the pair. They were kept aligned by hand at every claim, everyfinally, and two separate teardown paths that agreed only because one called the other in the right order.The ref half is redundant: state replacement in the controller is synchronous, so a claim is visible to the next
getState()in the same task, and the guard can read the map it already writes.createPendingClaimputs both halves behind one compare-and-set. Gone with it:addPendingSessionAction/clearPendingSessionActionand their optional-setter parameter that both call sites always passed, three private copies ofomitSessionKeythat existed only to maintain the map half, the fourset*BySessionsetters those copies fed, and one of the two teardown paths.The pending registry collapses onto the turn footer.
useKeyedPendingRegistrywas generic overtrackStateandautoClearMsbecause it served three instances. Two moved to the store above, and both options had only ever had one consumer — the turn footer needs the reactive snapshot for its disabled mask and the timers so a droppedsessions:changedcannot disable a button forever.useTurnActionRegistrysays that directly. Separately,clearAll()had no caller: the unmount cleanup inapp-shell-effectswalkedtimersRefandkeysRefitself, reaching around the method that exists to do it. It now callsclearAll(), which is whytimersRefno longer needs to be public.The store is reached through its controller.
useAppShellSessionUiStatereturned the controller plus a member-by-member copy;useAppShellSessionWorkspacecopied that copy; AppShell destructured the result. Adding a map meant editing three lists nothing keeps in agreement, andsessionUiControllerwas already in the same destructure. The hook now returns the controller. 27 call sites name it — longer to read, shorter to keep true.The delegating facade gets one home and a contract test.
stable-actions.tswas split from its hook on the stated grounds that staying React-free made it testable fromnode:test. No such test existed andcreateDelegatingActionshad one consumer. The claim is honoured the other way round: the facade moves in withuseStableActions, becomes private, and the contract is asserted through the hook, where React's commit semantics are part of what is being promised.Refs #4109
Verification
tscfor the renderer, main, and preload configs — cleanbiome formatacross the repository,biome checkon every touched file — cleansession-rail-render-contract— passes, which exercises this rewiring end to end in a real windowBoth new assertions were checked for falsifiability.
return actionsin place of the facade fails withthe facade itself is re-created; the pending-claim tests fail if the claim stops being compare-and-set.Not run: the full repository suite.
Behaviour change
setPermissionModenow claims before its bypass confirmation rather than after, so a second click cannot open a second dialog. The cost is that the control reads as pending while the user decides, which is what is true. A cancelled confirmation releases the claim.Review focus
The identity contract is the part worth arguing about. It constrains the mechanism — identities fixed across renders, calls delegating to the latest committed closures — rather than each of the nine
useStableActionscall sites, so it covers them by construction. That is deliberate: per-factory tests would need dependency graphs only AppShell can assemble, and they would assert the same thing nine times.What no identity test can catch is a factory that goes through neither the facade nor a once-created object. A bare function declaration in a hook body — the actual #4109 defect — keeps its dependency arrays correct and has nothing for
useExhaustiveDependenciesto flag. That gap is why the Session rail also carries the outcome budget from #4110, and why the comment insession-workspace-actions.tsthat used to argue one mechanism over the other now defers to the tests. Two mechanisms for stable identity remain, and that is intended: the facade exists for factories whose closures capture changing deps, and a factory whose deps are structurally constant should not pay for it.AI use
Select exactly one:
Tool(s) and scope: Claude Code. It ran the audit that found these four, traced the demand chains behind each removal, wrote the change and the tests, and drafted this description. The human contributor reviewed the diff, the commit messages, and the behaviour change above.
Generated-bytrailers are on all four commits.Checklist
Does this PR entail a change in behavior?